fix(fetch): remove abort listener when request settles#5318
Conversation
fetch() registers an `abort` listener on the passed AbortSignal (in both the Request constructor and the fetch algorithm) but only removed it via the FinalizationRegistry, i.e. on garbage collection. Reusing a single signal across many requests therefore accumulated listeners and Node.js emitted a MaxListenersExceededWarning. Capture the listener-removal callbacks and invoke them deterministically once the fetch settles (end-of-body, network error and abort paths) so that no listeners are leaked when a signal is reused. Closes nodejs#5285
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds deterministic cleanup for AbortSignal listeners used by fetch()/Request to prevent listener leaks and MaxListenersExceededWarning when reusing a single signal across many requests.
Changes:
- Add request-level abort-listener cleanup plumbing in
Requestand expose it forfetch()to call. - Ensure
fetch()removes abort listeners on error/abort/end-of-body settlement paths. - Add a regression test covering listener leakage when reusing one
AbortSignalacross manyfetch()calls.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| test/fetch/issue-5285.js | Adds regression test asserting no leaked abort listeners / warnings when reusing a signal. |
| lib/web/fetch/request.js | Tracks and exposes deterministic removal of the listener that ties request signal to an external signal. |
| lib/web/fetch/index.js | Calls cleanup hooks so request/fetch abort listeners are removed when the fetch settles. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -228,6 +228,15 @@ function fetch (input, init = undefined) { | |||
| } | |||
| ) | |||
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5318 +/- ##
=======================================
Coverage 93.22% 93.23%
=======================================
Files 110 110
Lines 36599 36642 +43
=======================================
+ Hits 34121 34162 +41
- Misses 2478 2480 +2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| // deserializedError. | ||
|
|
||
| abortFetch(p, request, responseObject, controller.serializedAbortReason, controller.controller) | ||
| cleanupAbortListeners() |
There was a problem hiding this comment.
Please move this function call to abortFetch.
|
|
||
| // Allow the trailing end-of-body cleanup of the final request, which is | ||
| // scheduled in a microtask, to run before asserting. | ||
| await new Promise((resolve) => setTimeout(resolve, 100)) |
There was a problem hiding this comment.
const { setImmediate } = require('node:timers/promises')| await new Promise((resolve) => setTimeout(resolve, 100)) | |
| await setImmediate() |
| // otherwise a MaxListenersExceededWarning is emitted and the listeners leak. | ||
| for (let i = 0; i < 100; i++) { | ||
| const res = await fetch(url, { signal }) | ||
| await res.text() |
There was a problem hiding this comment.
| await res.text() | |
| await res.arrayBuffer() |
There was a problem hiding this comment.
This is just a personal preference. It avoids string allocation, which makes the tests faster.
mcollina
left a comment
There was a problem hiding this comment.
I'm surprised this does not cause regressions, but in hindsight it should be ok
lgtm but we need to wait @KhafraDev opinion too.
I agree. A possible issue is that If this has no consequences, I think this should be upstreamed to the fetch spec. At the bare minimum, a note stating "the abort listener added can be removed here" could be added to the call sites. |
|
At first, I didn’t think this would work, but after thinking it through more carefully, it actually seems to work quite well. That said, since this behavior is outside the specification, we should proceed with caution. I found one edge case that is worth considering. In this scenario, determining when it is safe to detach the signal is very similar to how garbage collection works. Specifically, the link can only be removed when:
As far as I can tell, only browsers handle this edge case correctly today; it has not yet been implemented in server-side fetch. This is not an immediate problem since it is currently unimplemented, but it will likely become an issue if we decide to implement it later. const c = new AbortController();
const r = await fetch(
new URL("/", globalThis.location?.href ?? "https://example.com/"),
{ signal: c.signal },
);
const r2 = r.clone(); // Clone it.
await r.arrayBuffer(); // Consume the entire body first to confirm the request has ended.
c.abort(); // Abort.
console.log((await r2.text()).slice(0, 100)); // This should throw an error because they are shared.If this is a known limitation and we are comfortable with it for now, I’m fine with moving forward. However, it is something we will need to take into account if and when we implement this in the future. |
I can't find anything in the spec that says this case should throw. Browsers seem to be mishandling it. |
It seems like the browser is correct. const c = new AbortController();
const r = await fetch(
new URL("/", globalThis.location?.href ?? "https://example.com/"),
{ signal: c.signal },
);
const r2 = r.clone(); // Clone it.
c.abort(); // Abort.
console.log((await r2.text()).slice(0, 100)); // This throw an error because they are shared. |
Based on what? Once the body is consumed, the stream is no longer readable and therefore is never canceled/errored, which propagates to the teed stream(s) (seemingly, I could be wrong as I'm not an expert in webstreams). In the 2nd example, since the first response's body's stream is still readable when the signal is aborted, it errors. Spec:
|
|
Good question. I agree that this is not The point is the body stream. Your reading of Browsers appear to preserve the fetch-backed body relationship across the tee branches created by So the browser behavior is better described as preserving the fetch-abort/error linkage across cloned body branches, not as |
|
Browsers are actually handling this correctly, but for different reasons - we aren't teeing the streams 'properly', and I'm not sure we are able to without access to ReadableStreamTee. #rs-tee (webstreams) (this is how ReadableStream.prototype.tee is implemented):
"teeing" links to #readablestream-tee (webstreams) (how .clone() tees the streams):
... which has the note:
So there is a bug in undici, but this PR is not responsible for it, and will not cause any issues in implementing it if we ever have access to ReadableStreamTee from node core. |
|
Thanks for clarifying — that makes sense. I agree this should be framed as a Fetch body-clone issue, not as Fetch body cloning uses the Streams “teeing” operation corresponding to Given that, browsers are handling this correctly. This PR does not introduce the bug; it exposes an existing limitation that should be tracked separately until an equivalent of |
Problem
fetch()attaches anabortlistener to the passedAbortSignalon every call — in theRequestconstructor and in the fetch algorithm — but only removes them via aFinalizationRegistry(on GC). Reusing one signal across many requests accumulates listeners and Node emitsMaxListenersExceededWarning.Fix
Capture the listener-removal callbacks and invoke them deterministically once the fetch settles, covering the end-of-body, network-error and abort paths. The
Requestconstructor exposes its cleanup through an internal static accessor following the existing pattern inrequest.js, so no new public symbol is introduced.Test
Added
test/fetch/issue-5285.js: issues 100fetchcalls sharing oneAbortControllersignal and asserts noabortlisteners remain and noMaxListenersExceededWarningis emitted. Fails onmain, passes here. Fulltest/fetchsuite (471 tests) and node-fetch suite pass.Closes #5285